
### Вычисления хеша для объектов классов. 

Вначале что это такое и зачем нужно? В Python имеется специальная функция:

```python
hash(123)
hash("Python")
hash((1, 2, 3))
```

которая формирует по определенному алгоритму целочисленные значения для неизменяемых объектов. 

Причем, для равных объектов на выходе всегда должны получаться равные хэши:

```python
hash("Python")
hash((1, 2, 3))
```
А вот обратное утверждение делать нельзя: равные хэши не гарантируют равенство объектов. 

Это, как в известном выражении: селедка – это рыба, но не каждая рыба селедка. С хэшами все то же самое.


Однако, если хеши не равны, то и объекты точно не равны. 

Получаем следующие свойства для хеша:

Если объекты `a == b` (равны), то равен и их хэш.

Если равны хеши: `hash(a) == hash(b)`, то объекты могут быть равны, но могут быть и не равны.

Если хеши не равны: `hash(a) != hash(b)`, то объекты точно не равны.
Причем, обратите внимание, хэши можно вычислять только для неизменяемых объектов. 

Например, для списков:

```python
hash([1, 2, 3])
```
получим ошибку `«unhashable type»` - не хэшируемый объект.

Итак, мы увидели, что для любого неизменяемого объекта можно вычислять хэш с помощью функции `hash()`, но зачем все это надо? 

В действительности некоторые объекты в `Python`, например, словари используют хэши в качестве своих ключей.

Вспомним, когда мы у словаря указываем ключ, то он должен относиться к неизменяемому типу данных:

```python
d = {}
d[5] = 5
d["python"] = "python"
d[(1, 2, 3)] = [1, 2, 3]
В действительности, это необходимо, чтобы можно было вычислить хеш объектов и ключи хранить в виде:
```
> (хэш ключа, ключ)

Для чего это понадобилось? 

Дело в том, что первоначально нужная запись в словаре ищется по хэшу, так как существует быстрый алгоритм поиска нужного значения хэша. 

А, затем, для равных хешей (если такие были обнаружены), отбирается запись с указанным в ключе объекте. 

Такой подход значительно ускоряет поиск значения в словаре.

Думаю, теперь вы представляете себе, что такое хэш и зачем он нужен. 

Давайте теперь рассмотрим простой класс Point для представления координат на плоскости:

```python
class Point:
    def __init__(self, x, y):
        self.x = x
        self.y = y
```
Для экземпляров этого класса:

```python
p1 = Point(1, 2)
p2 = Point(1, 2)
```

мы совершенно спокойно можем вычислять хеш:

```python
print(hash(p1), hash(p2), sep='\n')
```

Обратите внимание, несмотря на то, что координаты точек p1 и p2 равны, их хэши разные. 
То есть, с точки зрения функции hash() – это два разных объекта. Но как она понимает, равные объекты или разные? 
Все просто. Если оператор сравнения:

```python
print(p1 == p2)
```
дает True, то объекты равны, иначе – не равны.

Соответственно, для разных объектов будут получаться и разные хэши. Но раз это так, что будет, если мы переопределим поведение этого оператора сравнения с помощью магического метода `__eq__()`? 

Давайте попробуем:

```python
    def __eq__(self, other):
        return self.x == other.x and self.y == other.y
```
Теперь у нас объекты с одинаковыми координатами будут считаться равными. Но при запуске программы возникает ошибка «unhashable type», то есть, наши объекты стали не хэшируемыми. 

Да, как только происходит переопределение оператора ==, то начальный алгоритм вычисления хэша для таких объектов перестает работать.

Поэтому, нам здесь нужно прописать свой способ вычисления хэша объектов через магический метод` __hash__()`, например, так:

```python
    def __hash__(self):
        return hash((self.x, self.y))
```

Мы здесь вызываем функцию hash для кортежа из координат точки. Этот кортеж относится к неизменяемому типу, поэтому для него можно применить стандартную функцию `hash()`. 

То есть, мы подменили вычисление хэша объекта класса `Point` на вычисление хэша от координат точки.

Теперь, после запуска программы видим, что объекты равны и их хэши также равны.

Что это в итоге означает? Смотрите, если взять пустой словарь:
```python
d = {}
```
А, затем, сформировать записи через объекты `p1` и `p2`:
```python
d[p1] = 1
d[p2] = 2
print(d)
```
то они будут восприниматься как один и тот же ключ, так как объекты равны и их хэши тоже равны. 

А вот если магические методы в классе `Point` поставить в комментарии и снова запустить программу, то увидим, что это уже разные объекты, которые формируют разные ключи словаря.

Вот для чего может понадобиться тонко настраивать работу функции `hash()` для объектов классов.



### Магические методы `__getitem__, __setitem__ и __delitem__`


_`_getitem__(self, item)` – получение значения по ключу item;

`__setitem__(self, key, value)` – запись значения value по ключу key;

`__delitem__(self, key)` – удаление элемента по ключу key.

Давайте разберемся для чего они нужны и как их можно использовать. 

Предположим, что мы создаем класс для представления студентов:

```python
class Student:
    def __init__(self, name, marks):
        self.name = name
        self.marks = list(marks)
```
Его экземпляр можно сформировать, следующим образом:

```python
s1 = Student('Сергей', [5, 5, 3, 2, 5])
```

В объекте `s1` имеется локальное свойство marks со списком студентов. 

Мы можем к нему обратиться и выбрать любую оценку:

```python
print(s1.marks[2])
```
Но что если мы хотим делать то же самое, но используя только ссылку на объект s1:

```python
print(s1[2])
```

Если сейчас запустить программу, то увидим сообщение об ошибке, что наш класс (объект) не поддерживает такой синтаксис. 

Как вы, наверное, уже догадались, поправить это можно с помощью магического метода `__getitem__`. 

Запишем его в нашем классе, следующим образом:
```python
    def __getitem__(self, item):
        return self.marks[item]
```

Теперь ошибок нет и на экране видим значение 3. Однако, если указать неверный индекс:

```python
print(s1[20])
```
то получим исключение `IndexError`, которое сгенерировал список marks. 

При необходимости, мы можем сами контролировать эту ошибку, если в методе `__getitem__` пропишем проверку:

```python
    def __getitem__(self, item):
        if 0 <= item < len(self.marks):
            return self.marks[item]
        else:
            raise IndexError("Неверный индекс")
```
При запуске программы видим наше сообщение «Неверный индекс». Также можно сделать проверку на тип индекса:

```python
print(s1['abc'])
```

для списков он должен быть целым числом. Поэтому дополнительно можно записать такую проверку:

```python
    def __getitem__(self, item):
        if not isinstance(item, int):
            raise TypeError("Индекс должен быть целым числом")
 
       if 0 <= item < len(self.marks):
            return self.marks[item]
        else:
            raise IndexError("Неверный индекс")
```

То есть, здесь возможны самые разные вариации обработки и проверки исходных данных, прежде чем обратиться к списку marks и вернуть значение.

Теперь давайте предположим, что хотели бы иметь возможность менять оценки студентов, используя синтаксис:

```python
s1[2] = 4
print(s1[2])
```
Сейчас, после запуска программы будет ошибка TypeError, что объект не поддерживает операцию присвоения, так как в классе не реализован метод `__setitem__`
. 
Давайте добавим и его:

```python
    def __setitem__(self, key, value):
        if not isinstance(key, int) or key < 0:
            raise TypeError("Индекс должен быть целым неотрицательным числом")
 
        self.marks[key] = value
```
Однако, если мы сейчас укажем несуществующий индекс:

```python
s1[6] = 4
```
то операция присвоения новой оценки приведет к ошибке.

Если предполагается использовать такую возможность, то реализовать ее можно, следующим образом:

```python
    def __setitem__(self, key, value):
        if not isinstance(key, int) or key < 0:
            raise TypeError("Индекс должен быть целым неотрицательным числом")
 
        if key >= len(self.marks):
            off = key + 1 - en(self.marks)
            self.marks.extend([None]*off)
 
        self.marks[key] = value
```

Если индекс превышает размер списка, то мы расширяем список значениями None до нужной длины (с помощью метода extend), а затем, в последний элемент записываем переданное значение value. 

Теперь, при выполнении команд:

```python
s1[10] = 4
print(s1.marks)
```
Увидим список:

```python
[5, 5, 3, 2, 5, None, None, None, None, None, 4]
```

То есть, он был расширен до 10 элементов и последним элементом записано 4. 

И так можно прописывать любую нужную нам логику при записи новых значений в список marks.

Наконец, последний третий магический метод `__delitem__` вызывается при удалении элемента из списка. 

Если сейчас записать команду:

```python
del s1[2]
```
то в консоли увидим сообщение:
> «AttributeError: `__delitem__»`. 

Здесь явно указывается, что при удалении вызывается метод `__delitem__`. 

Добавим его в наш класс:

```python
    def __delitem__(self, key):
        if not isinstance(key, int):
            raise TypeError("Индекс должен быть целым числом")
 
        del self.marks[key]
```

Теперь оценки успешно удаляются, если указан верный индекс.

Вот общие возможности данных магических методов.

 Теперь, если в программе вам потребуется реализовать подобную логику поведения экземпляров классов, я думаю, вы легко со всем справитесь.